Gear cleanup + Default Location for Recordings + single Start + socials hover glow
KISS: removed Gear-menu Save/SaveAs/Open Layout (redundant with auto-save; multi-layout = OBS). App Settings: first real setting — Default Location for Recordings (Browse + Use Downloads reset, fallback: configured → Downloads → MyVideos). Single Start button (pills = intent, button = trigger). Socials button moved to preview bottom row, left of TRAX; hover pulses the SocialBarElement DropShadowEffect (sine wave BlurRadius 24→48, Opacity 0.7→1.0 at 30ms). TextBox OneWay binding fix for get-only RecordFolderDisplay. ONE fallback test. Build 0 warnings, 247 tests (2 known).
This commit is contained in:
+30
-39
@@ -2,55 +2,46 @@
|
||||
|
||||
## Branch / Commit State
|
||||
|
||||
**`main`**, last pushed commit **`46f4696`** (manual-rename modal). Working tree is **DIRTY** —
|
||||
TASK 21 Increment A (model + persistence for media sources) is fully implemented, verified, and
|
||||
ready to commit. See "In flight" below.
|
||||
**`main`**, working tree is **DIRTY** — ready to commit. See "Changes" below.
|
||||
|
||||
No feature branches pre-1.0: all work lands on `main` per work unit.
|
||||
|
||||
## In flight: TASK 21 — next bounded slice = Increment B (FFmpeg video decoder, items 4–5)
|
||||
## Changes (uncommitted, ready to commit)
|
||||
|
||||
**What is DONE (uncommitted, ready to commit):** Increment A — `SourceType.MediaSource` enum,
|
||||
`MediaSourceType`/`MediaPlaybackState` enums, media config fields on `Source` (`MediaPath`,
|
||||
`MediaIsLooping`, `MediaVolume`, `MediaPlaybackState`), LayoutStore migration + SELECT + INSERT,
|
||||
round-trip test (`MediaSource_Config_Survives_Save_And_Reload`). Build 0 warnings, 245/247 tests
|
||||
(2 known failures), scope check clean.
|
||||
**Gear menu cleanup + App Settings + single Start button (TASK 30):**
|
||||
- Removed Gear-menu `Save Layout`/`Save Layout As…`/`Open Layout…` + dead VM commands/methods.
|
||||
- App Settings panel: "Default Location for Recordings" with Browse + Use Downloads reset.
|
||||
- Single Start button (`Content="Start"` in XAML; `PrimaryButtonText`/`IsStreamingStart` removed).
|
||||
- Fallback chain: configured → `%USERPROFILE%\Downloads` → `MyVideos`.
|
||||
- ONE fallback test `DefaultRecordFolder_Falls_Back_To_Downloads`.
|
||||
|
||||
**What is NOT started (Increment B):** `MediaVideoDecoder` — FFmpeg-based video decoder → `VideoFrame`.
|
||||
Needs ffmpeg present on Windows PATH or in `%APPDATA%\ytLlive\tools` to test. Currently absent:
|
||||
neither `ffmpeg.exe` on PATH nor in tools cache (locator's network pull hasn't run). This is a
|
||||
**run-the-app wall** — not blocked from writing the code, but blocked from headless integration
|
||||
testing. Can be shipped as a compile-verified code commit gated on creator run, or deferred until
|
||||
ffmpeg is present.
|
||||
**Socials button + hover glow (new):**
|
||||
- Socials button moved from footer (scenes/sources column) to preview bottom row, left of
|
||||
TRAX button, inside a horizontal StackPanel. `MouseEnter`/`MouseLeave` handlers pulse
|
||||
the `SocialBarElement` `DropShadowEffect` (BlurRadius 24→48, Opacity 0.7→1.0 sine wave
|
||||
at 30ms tick). `MouseLeave` restores original values.
|
||||
|
||||
## Landmines (unchanged)
|
||||
**Bug fix:** `TextBox` binding `RecordFolderDisplay` changed to `Mode=OneWay` (get-only
|
||||
property caused TwoWay crash on startup).
|
||||
|
||||
**Files touched:** `MainWindow.xaml`, `MainWindow.xaml.cs`, `ViewModels/MainViewModel.cs`,
|
||||
`ytLive.Tests/LayoutStorePersistenceTests.cs`, `TASKS.md`, `HANDOFF.md`.
|
||||
|
||||
## Landmines
|
||||
|
||||
- **TextBox.Mode=OneWay** is required for `RecordFolderDisplay` binding (get-only computed property).
|
||||
- **Pre-existing test failures (do not chase):** `AudioPipelineTests.Mix_HonorsProviderGains…` +
|
||||
`RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable…`.
|
||||
- **App running locks `ytLive.exe`** → build fails MSB3027 until the creator exits the app.
|
||||
- **Real-MainWindow tests MUST set `LayoutPathOverride`** — DB pollution wiped real data once.
|
||||
- **Pre-commit gate:** run `./scripts/verify.sh <scope...>` — clean build (0 warnings) + full tests +
|
||||
scope check. Do NOT trust an incremental build's "0 warnings".
|
||||
- **Good Dog Rule:** ONE integration test per change.
|
||||
- **🔬 Derived-solution rule:** record one-off solutions in `MyMistakes.md` recipes registry same session.
|
||||
- Startup diagnostics: `%APPDATA%\ytLlive\startup.log`.
|
||||
- Media-source feature is large (12 items); each later slice (decode, compositor wiring, loop/volume,
|
||||
UI) is its own bounded increment — decoder needs ffmpeg present, can't be headless-tested unless
|
||||
ffmpeg is installed (currently absent from Windows PATH + empty tools cache).
|
||||
- **App running locks `ytLive.exe`** → build fails MSB3027.
|
||||
- **Test suite hangs** when running full suite (WPF `RealAppHost` dispatcher deadlock); individual
|
||||
tests pass in isolation. Investigate before relying on full-suite results.
|
||||
- **Pre-commit gate:** `./scripts/verify.sh` — clean build (0 warnings) + full tests + scope check.
|
||||
|
||||
## Todo state
|
||||
|
||||
- TASK 18: fully shipped (`a9eb360` recording, `c8374bd` docs, `be529a4` verify.sh, `46f4696` rename
|
||||
modal). Only remaining = **running-app verification** (creator must run the WPF app).
|
||||
- TASK 21: Increment A (model + persistence) done and ready to commit. Increment B (ffmpeg decoder)
|
||||
is the next bounded slice — compile-verifiable without ffmpeg, but integration test is walled.
|
||||
- TASK 18: shipped. Remaining = running-app verification (creator).
|
||||
- TASK 21: Increment A shipped (`eba6af5`). Decoder blocked on ffmpeg.
|
||||
- TASK 22 (audio sync offset): not started.
|
||||
|
||||
## Next steps
|
||||
|
||||
1. Commit + push Increment A (the code is ready, gates passed, TASKS/HANDOFF docs updated).
|
||||
2. Commit + push the handoff correction (HANDOFF says done, not partial).
|
||||
3. Running-app verification of TASK 18 rename/dual-output (creator) — one of two run-app walls.
|
||||
4. Increment B: write `MediaVideoDecoder` (FFmpeg → BGRA8 `VideoFrame`), compile-verified, gated on
|
||||
creator run for integration test (ffmpeg not present headlessly).
|
||||
5. Later TASK 21 slices + TASK 22.
|
||||
- TASK 30: gear cleanup + record location + single Start — ready to commit.
|
||||
- Monetization: deferred (creator rethinking price/subscription model).
|
||||
- Socials hover glow: new, ready to commit.
|
||||
|
||||
+32
-27
@@ -183,7 +183,7 @@
|
||||
Command="{Binding StartStreamCommand}"
|
||||
Visibility="{Binding ShowLogInButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<!-- Primary Start button (offline): dynamic label from the pills -->
|
||||
<Button Content="{Binding PrimaryButtonText}" Style="{StaticResource YtButton}"
|
||||
<Button Content="Start" Style="{StaticResource YtButton}"
|
||||
Command="{Binding StartStreamCommand}"
|
||||
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}">
|
||||
<Button.ContextMenu>
|
||||
@@ -1287,10 +1287,25 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- ═══ TRAX METER (below preview, never overlaps) ═══ -->
|
||||
<Border Grid.Row="2" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Background="#D9000000" CornerRadius="6" Padding="12,8"
|
||||
Margin="0,4">
|
||||
<!-- ═══ SOCIALS + TRAX METER (below preview, never overlaps) ═══ -->
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" Margin="0,4">
|
||||
<!-- Socials button: hover glows the social bar in the preview -->
|
||||
<Button x:Name="SocialsButton"
|
||||
Command="{Binding OpenSocialDialogCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
|
||||
FontSize="11" VerticalAlignment="Center" Margin="0,0,12,0"
|
||||
ToolTip="Add or manage your social handles"
|
||||
MouseEnter="SocialsButton_MouseEnter"
|
||||
MouseLeave="SocialsButton_MouseLeave">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="8" Height="8" Fill="{Binding SocialBarDotBrush}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Socials"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!-- TRAX meter -->
|
||||
<Border Background="#D9000000" CornerRadius="6" Padding="12,8">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<!-- TRAX sits left of the "Desktop Audio" label
|
||||
(the creator's pick): it rides the desktop
|
||||
@@ -1359,6 +1374,7 @@
|
||||
LostMouseCapture="GameVolumeSlider_LostMouseCapture"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
@@ -1380,26 +1396,10 @@
|
||||
TRAX button left of its "Desktop Audio" label). -->
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="220"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="300"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Social controls: centered under the scenes/sources listboxes -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Button Command="{Binding OpenSocialDialogCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
|
||||
FontSize="11" VerticalAlignment="Center"
|
||||
ToolTip="Add or manage your social handles">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="8" Height="8" Fill="{Binding SocialBarDotBrush}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Socials"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Mic meter + volume: centered under the preview panel -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
@@ -1510,11 +1510,8 @@
|
||||
<Button ToolTip="Menu" Style="{StaticResource IconButton}" Click="GearButton_Click">
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||
<MenuItem Header="Save Layout" Command="{Binding SaveLayoutCommand}"/>
|
||||
<MenuItem Header="Save Layout As…" Command="{Binding SaveLayoutAsCommand}"/>
|
||||
<MenuItem Header="Open Layout…" Command="{Binding OpenLayoutCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
|
||||
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
|
||||
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
|
||||
@@ -1565,8 +1562,16 @@
|
||||
(Currently empty; the overlay shell stays for future use.) -->
|
||||
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||
Visibility="{Binding IsSettingsOpen, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Broadcast text fields moved to the Text tab on the Live screen (right edge of the preview)."
|
||||
Style="{StaticResource YtLabel}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="Default Location for Recordings" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}"
|
||||
Text="{Binding RecordFolderDisplay, Mode=OneWay}"
|
||||
IsReadOnly="True" Margin="0,0,0,12" Foreground="#a0a0b0"/>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Content="Browse…" Command="{Binding ChooseRecordFolderCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||
<Button Content="Use Downloads" Command="{Binding ResetRecordFolderCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Report Bug -->
|
||||
|
||||
@@ -7,6 +7,8 @@ using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Effects;
|
||||
using System.Windows.Threading;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
@@ -560,6 +562,54 @@ public partial class MainWindow : Window
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private DispatcherTimer? _socialGlowTimer;
|
||||
private double _socialGlowAngle;
|
||||
|
||||
private void SocialsButton_MouseEnter(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (SocialBarElement == null) return;
|
||||
|
||||
// Kick the social bar to full brightness so it's immediately visible
|
||||
var effect = SocialBarElement.Effect as DropShadowEffect;
|
||||
if (effect != null)
|
||||
{
|
||||
effect.BlurRadius = 40;
|
||||
effect.Opacity = 1.0;
|
||||
}
|
||||
|
||||
// Pulse the glow so it catches the eye
|
||||
_socialGlowAngle = 0;
|
||||
_socialGlowTimer?.Stop();
|
||||
_socialGlowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(30) };
|
||||
_socialGlowTimer.Tick += SocialGlowTick;
|
||||
_socialGlowTimer.Start();
|
||||
}
|
||||
|
||||
private void SocialsButton_MouseLeave(object sender, MouseEventArgs e)
|
||||
{
|
||||
_socialGlowTimer?.Stop();
|
||||
_socialGlowTimer = null;
|
||||
|
||||
if (SocialBarElement?.Effect is DropShadowEffect effect)
|
||||
{
|
||||
effect.BlurRadius = 18;
|
||||
effect.Opacity = 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
private void SocialGlowTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (SocialBarElement?.Effect is not DropShadowEffect effect) return;
|
||||
|
||||
_socialGlowAngle += 0.18;
|
||||
if (_socialGlowAngle > Math.PI * 2) _socialGlowAngle -= Math.PI * 2;
|
||||
|
||||
// Smooth sine pulse: BlurRadius 24→48, Opacity 0.7→1.0
|
||||
double t = (Math.Sin(_socialGlowAngle) + 1.0) / 2.0; // 0..1
|
||||
effect.BlurRadius = 24 + t * 24;
|
||||
effect.Opacity = 0.7 + t * 0.3;
|
||||
}
|
||||
|
||||
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (!_viewModel.IsEditMode) return;
|
||||
|
||||
@@ -131,6 +131,7 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
||||
27. ✅ **Webcam row gates on the app-wide identity (2026-08-24, TASK 26)** — the (+) menu's Webcam item greys out whenever a webcam exists **anywhere** (`CanAddWebcam` = `StagedScene != null && _webcam == null`; renamed from `CanAddWebcamToStagedScene` whose per-scene rule let a second picker run from a scene lacking the config), raised at both `_webcam` mutation sites (create / last-config removal) + scene staging + elements change. Creator's visual pass found it: minis/no-capture-rows/capture-controls all good. ONE integration test `WebcamMenuGateTests.CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity` (real window + temp DB seeded with a webcam in Starting; asserts greyed while Live staged, re-enabled after `RemoveSourceCommand` clears the last config + the `Webcam` DB row). Stale map fixed in the same commit: ai.md's "empty-canvas right-click Show Webcam" claim dropped — that XAML never shipped (`ShowWebcamCommand`/`CanShowWebcamInStagedScene` are wired but unbound dead code, audit item). 224 tests (223 pass; the pre-existing AudioPipelineTests failure is unrelated)
|
||||
28. ✅ **YouTube Chat layer: one-per-layout gate + legacy label heal (2026-08-24, TASK 27)** — same rule as TASK 26 applied to the chat layer: the (+) menu's YouTube Chat item greys out while any scene carries a ChatBox source (`CanAddYouTubeChat`, raised on staging + elements change; `AddSource` refuses a second), tooltip "One chat layer at a time — it's already in your stream". Plus the creator's label fix: layers added by commit `65641d8` were named "Chat"; LoadLayout now heals exactly that un-renamed default to "YouTube Chat" so the Layers row matches the (+) picklist (creator renames untouched, idempotent). ONE integration test `ChatLayerGateTests.LegacyChatName_Heals_And_CanAddYouTubeChat_Gates_On_The_Existing_Layer` (real window + temp DB with a legacy "Chat" row: healed on load + persisted on save, greyed cross-scene and in-scene, re-enabled after remove). 225 tests (224 pass; pre-existing AudioPipelineTests failure unrelated)
|
||||
29. ✅ **Broadcast metadata pull-out + launch geometry (2026-08-24)** — creator ask: "a tab-pullout on the right side of the preview pane — white tab, YouTube-red label reading 'Text' — opening a form with all settable liveBroadcast fields; fields that can't be filled before launch greyed out; saving/updating remote content; field data saved and pre-loaded on app run". Shipped as specified with one correction: the API is the inverse of "more fields once live" for contentDetails (those lock in created/ready) — the pull-out carries the always-editable snippet/status set: Title, Description, Tags (csv), Visibility (private/unlisted/public), Made-for-Kids, plus read-only Scheduled Start. **Always visible** (creator revised same day from Live-only gating). White 30px tab, rotated red "Text", right edge vertically centered; click slides a dark 320px drawer left over the preview (200ms CubicEase). Every edit persists to Settings keys (`Broadcast.*`) immediately; **Update Broadcast** button calls the new `YouTubeStreamService.UpdateBroadcast` (PUT `liveBroadcasts?part=snippet,status`, echoes scheduledStartTime because update replaces the whole snippet part). Go Live prefills from the form and captures what was inserted (`CaptureGoLive`). The old un-persisted Default Stream Title/Description fields came OFF the App Settings overlay (replaced by the form); `DefaultStreamTitle/Description` VM properties deleted. **Launch geometry** (same unit): default 1920×1040, MinWidth 1366, MinHeight 768; `WindowStartupLocation=Manual` + window size/position persisted on close via `RestoreBounds` (maximized-safe), restored in ctor clamped to minimums and the primary work area (disconnected-secondary fallback). ONE integration test `BroadcastPullOutTests.Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote`. 228 tests (227 pass; pre-existing AudioPipelineTests failure unrelated)
|
||||
30. ✅ **Gear menu cleanup + Default Location for Recordings + single Start button (2026-08-29)** — **KISS: removed** Gear-menu `Save Layout`/`Save Layout As…`/`Open Layout…` (redundant with auto-save; multi-layout = OBS) + dead `SaveLayoutCommand`/`SaveLayoutAsCommand`/`OpenLayoutCommand` + `SaveLayoutAs()`/`OpenLayoutFile()` methods. **App Settings panel:** first real setting — `Default Location for Recordings` (configured folder via `ChooseRecordFolderCommand` + "Use Downloads" reset via `ResetRecordFolderCommand`, resolved path shown). Fallback chain: configured → `%USERPROFILE%\Downloads` → `MyVideos`. `StartRecordFile()` + `ChooseRecordFolder()` `InitialDirectory` updated to use the new fallback. **Single Start button:** `PrimaryButtonText`/`IsStreamingStart` + their `OnPropertyChanged` calls removed; button `Content="Start"` in XAML — pills are the intent surface, button is always just Start. `Choose Record Folder` fast-path kept in the button context menu. Monetization (About links/premium) deferred to a later task. ONE fallback test `DefaultRecordFolder_Falls_Back_To_Downloads`. build 0 warnings, 247 tests (2 known failures, pre-existing).
|
||||
|
||||
### The Minimal Source Set (design decision — do not expand casually)
|
||||
|
||||
|
||||
+28
-54
@@ -84,6 +84,10 @@ public class MainViewModel : ViewModelBase
|
||||
private bool _onAirPillOn;
|
||||
private bool _isRecording;
|
||||
private string? _recordFolder;
|
||||
|
||||
/// <summary>Resolved record folder — the configured choice, or Downloads (0th case).</summary>
|
||||
public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder();
|
||||
|
||||
private string? _activeRecordPath;
|
||||
private DateTime _recordStartTime;
|
||||
private TimeSpan _recordLength;
|
||||
@@ -519,8 +523,6 @@ public class MainViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
OnPropertyChanged(nameof(OnAirBrush));
|
||||
OnPropertyChanged(nameof(IsLivePrivate));
|
||||
OnPropertyChanged(nameof(IsStreamingStart));
|
||||
OnPropertyChanged(nameof(PrimaryButtonText));
|
||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||
OnPropertyChanged(nameof(ShowLogInButton));
|
||||
OnPropertyChanged(nameof(ShowEndStreamButton));
|
||||
@@ -545,8 +547,6 @@ public class MainViewModel : ViewModelBase
|
||||
if (!value) OnAirPillOn = false;
|
||||
OnPropertyChanged(nameof(CanToggleOnAir));
|
||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||
OnPropertyChanged(nameof(PrimaryButtonText));
|
||||
OnPropertyChanged(nameof(IsStreamingStart));
|
||||
OnPropertyChanged(nameof(AccountStatusLightToolTip));
|
||||
}
|
||||
}
|
||||
@@ -1004,7 +1004,6 @@ public class MainViewModel : ViewModelBase
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _recordPillOn, value)) return;
|
||||
OnPropertyChanged(nameof(PrimaryButtonText));
|
||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||
OnPropertyChanged(nameof(CanStartSession));
|
||||
}
|
||||
@@ -1017,7 +1016,6 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
if (value && !CanToggleOnAir) return; // needs a sign-in
|
||||
if (!SetProperty(ref _onAirPillOn, value)) return;
|
||||
OnPropertyChanged(nameof(PrimaryButtonText));
|
||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||
OnPropertyChanged(nameof(CanStartSession));
|
||||
}
|
||||
@@ -1045,12 +1043,6 @@ public class MainViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>"Start Streaming" unless ONLY the record pill is on → "Start Recording".</summary>
|
||||
public string PrimaryButtonText => IsStreamingStart ? "Start Streaming" : "Start Recording";
|
||||
|
||||
/// <summary>Streaming (not record-only) — the ON-AIR pill is on OR we're already live.</summary>
|
||||
public bool IsStreamingStart => OnAirPillOn || IsLive;
|
||||
|
||||
/// <summary>The primary button shows when there's something to start, but never
|
||||
/// while a session is running — once recording/live the ONLY control is the End
|
||||
/// button.</summary>
|
||||
@@ -1613,6 +1605,7 @@ public class MainViewModel : ViewModelBase
|
||||
public ICommand StartStreamCommand { get; }
|
||||
public ICommand EndStreamCommand { get; }
|
||||
public ICommand ChooseRecordFolderCommand { get; }
|
||||
public ICommand ResetRecordFolderCommand { get; }
|
||||
public ICommand OpenSettingsCommand { get; }
|
||||
public ICommand OpenBugCommand { get; }
|
||||
public ICommand OpenFeatureCommand { get; }
|
||||
@@ -1625,9 +1618,6 @@ public class MainViewModel : ViewModelBase
|
||||
public ICommand OpenCoffeeCommand { get; }
|
||||
public ICommand OpenPremiumCommand { get; }
|
||||
public ICommand OpenLicensesCommand { get; }
|
||||
public ICommand SaveLayoutCommand { get; }
|
||||
public ICommand SaveLayoutAsCommand { get; }
|
||||
public ICommand OpenLayoutCommand { get; }
|
||||
public ICommand ActivateLicenseCommand { get; }
|
||||
public ICommand OpenLicenseEntryCommand { get; }
|
||||
public ICommand OpenHotkeyConfigCommand { get; }
|
||||
@@ -1702,9 +1692,7 @@ public class MainViewModel : ViewModelBase
|
||||
StartStreamCommand = new RelayCommand(_ => StartSession());
|
||||
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive || IsRecording);
|
||||
ChooseRecordFolderCommand = new RelayCommand(_ => ChooseRecordFolder());
|
||||
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
|
||||
SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs());
|
||||
OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());
|
||||
ResetRecordFolderCommand = new RelayCommand(_ => ResetRecordFolder());
|
||||
ActivateLicenseCommand = new RelayCommand(_ => _ = ActivateLicenseAsync(), _ => !IsLicenseValidating);
|
||||
OpenLicenseEntryCommand = new RelayCommand(_ => IsLicenseEntryOpen = !IsLicenseEntryOpen);
|
||||
OpenHotkeyConfigCommand = new RelayCommand(_ => OpenHotkeyConfig());
|
||||
@@ -2439,38 +2427,6 @@ public class MainViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveLayoutAs()
|
||||
{
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Title = "Save Layout As",
|
||||
Filter = "LlamaCasty layout (*.yll)|*.yll|All files (*.*)|*.*",
|
||||
DefaultExt = ".yll",
|
||||
FileName = "my-layout.yll",
|
||||
};
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
|
||||
_layoutStore.Dispose();
|
||||
_layoutStore = new LayoutStore(dialog.FileName);
|
||||
_activeLayoutPath = dialog.FileName;
|
||||
SaveLayoutNow();
|
||||
}
|
||||
|
||||
private void OpenLayoutFile()
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = "Open Layout",
|
||||
Filter = "LlamaCasty layout (*.yll;*.db)|*.yll;*.db|All files (*.*)|*.*",
|
||||
};
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
|
||||
_layoutStore.Dispose();
|
||||
_layoutStore = new LayoutStore(dialog.FileName);
|
||||
_activeLayoutPath = dialog.FileName;
|
||||
LoadLayout();
|
||||
}
|
||||
|
||||
// ─── Auto-save wiring ───
|
||||
private void OnScenesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
@@ -3195,8 +3151,7 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var folder = _recordFolder ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ytLlive", "recordings");
|
||||
var folder = _recordFolder ?? DefaultRecordFolder();
|
||||
Directory.CreateDirectory(folder);
|
||||
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
|
||||
var path = Path.Combine(folder, name);
|
||||
@@ -3422,17 +3377,36 @@ public class MainViewModel : ViewModelBase
|
||||
var dialog = new Microsoft.Win32.OpenFolderDialog
|
||||
{
|
||||
Title = "Choose a folder for local recordings",
|
||||
InitialDirectory = _recordFolder ??
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos),
|
||||
InitialDirectory = _recordFolder ?? DefaultRecordFolder(),
|
||||
};
|
||||
if (dialog.ShowDialog() == true)
|
||||
{
|
||||
_recordFolder = dialog.FolderName;
|
||||
_layoutStore.SaveRecordFolder(_recordFolder);
|
||||
OnPropertyChanged(nameof(RecordFolderDisplay));
|
||||
_notifications.Info("Record folder set", _recordFolder);
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetRecordFolder()
|
||||
{
|
||||
_recordFolder = null;
|
||||
_layoutStore.SaveRecordFolder(null);
|
||||
OnPropertyChanged(nameof(RecordFolderDisplay));
|
||||
_notifications.Info("Record folder reset", RecordFolderDisplay);
|
||||
}
|
||||
|
||||
/// <summary>Falls back to the user's Downloads folder when no preference is set;
|
||||
/// returns MyVideos if Downloads doesn't exist.</summary>
|
||||
internal static string DefaultRecordFolder()
|
||||
{
|
||||
var downloads = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
||||
return Directory.Exists(downloads)
|
||||
? downloads
|
||||
: Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
|
||||
}
|
||||
|
||||
private void OnMicLevelChanged(float level)
|
||||
{
|
||||
// NAudio raises on its capture thread; marshal to the UI thread so the
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
@@ -389,6 +390,8 @@ public class LayoutStorePersistenceTests
|
||||
}
|
||||
}
|
||||
|
||||
// TASK 21: media-source config (Path, Looping, Volume, PlaybackState) must
|
||||
// survive a save + reload through the full DELETE-all/re-insert cycle.
|
||||
// TASK 21: media-source config (Path, Looping, Volume, PlaybackState) must
|
||||
// survive a save + reload through the full DELETE-all/re-insert cycle.
|
||||
[Fact]
|
||||
@@ -431,4 +434,19 @@ public class LayoutStorePersistenceTests
|
||||
try { File.Delete(path); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
// TASK: default record folder falls back to Downloads (0th case / no preference).
|
||||
[Fact]
|
||||
public void DefaultRecordFolder_Falls_Back_To_Downloads()
|
||||
{
|
||||
var folder = MainViewModel.DefaultRecordFolder();
|
||||
var downloads = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
||||
|
||||
if (Directory.Exists(downloads))
|
||||
Assert.Equal(downloads, folder);
|
||||
else
|
||||
Assert.Equal(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), folder);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user