AuthenticateAsync()
+ {
+ const int port = 8765;
+ var redirectUri = $"http://localhost:{port}/oauth2/callback";
+
+ if (string.IsNullOrWhiteSpace(_clientId) || string.IsNullOrWhiteSpace(_clientSecret))
+ return null;
+
+ using var listener = new HttpListener();
+ listener.Prefixes.Add($"{redirectUri}/");
+ listener.Start();
+
+ Process.Start(new ProcessStartInfo(GetAuthorizationUrl(redirectUri)) { UseShellExecute = true });
+
+ HttpListenerContext context;
+ try
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
+ context = await listener.GetContextAsync().WaitAsync(cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+
+ var code = context.Request.QueryString["code"];
+ var error = context.Request.QueryString["error"];
+
+ var html = error != null
+ ? "Sign-in failed
You can close this window and return to ytLlive.
"
+ : "Sign-in successful!
You can close this window and return to ytLlive.
";
+ var buffer = Encoding.UTF8.GetBytes(html);
+ context.Response.ContentType = "text/html; charset=utf-8";
+ context.Response.ContentLength64 = buffer.Length;
+ await context.Response.OutputStream.WriteAsync(buffer);
+ context.Response.Close();
+
+ if (error != null || string.IsNullOrEmpty(code))
+ return null;
+
+ return await ExchangeCodeForToken(code, redirectUri);
+ }
+
public async Task ExchangeCodeForToken(string code, string redirectUri)
{
var body = new FormUrlEncodedContent(new Dictionary
diff --git a/TASKS.md b/TASKS.md
index b68a1bb..f6ba9f2 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -1,4 +1,35 @@
-# ytLive β Task List
+# ytLlive β Task List
+
+## YouTube Live API β research facts (authoritative, v3 build)
+
+Lifecycle: `created β ready β [testing] β live β complete` (transitional `liveStarting` / `testStarting`).
+
+- **liveBroadcasts.insert** requires: `snippet.title`, `snippet.scheduledStartTime`, `status.privacyStatus`, `status.selfDeclaredMadeForKids` (COPPA).
+- **liveStreams.insert** requires: `snippet.title`, `cdn.frameRate`, `cdn.ingestionType`, `cdn.resolution`. **None of the four (except title) can ever change after creation** β changing them means delete + recreate the stream. This is the hard constraint behind the quality grey-out.
+- **Title / description / privacy**: editable at any time, including while live (`liveBroadcasts.update`, part=`snippet,status`).
+- **contentDetails** (DVR, recordFromStart, monitorStream, embed, latency): editable only in `created` / `ready`.
+- **Transition to live** only allowed when the bound stream's `status.streamStatus == active`.
+
+### Two features that reshape the design
+
+1. **enableAutoStart / enableAutoStop** β instant one-click go-live, no transition call. With `enableAutoStart=true` we never call `transition(live)`: the broadcast auto-goes-live the moment the encoder starts. Combined with `enableMonitorStream=false` (our preview pane replaces YouTube's monitor stream β the thing that forces a testing stage), the flow is **create β bind β Start Stream β encoder starts β YouTube brings it live**. No testing, no transition polling, no liveStarting stuck-state handling.
+2. **`cdn.resolution=variable` / `cdn.frameRate=variable`** β free auto step-down. YouTube auto-detects what we send; since we ARE the encoder we can drop bitrate/resolution on the fly with zero API calls. Declaring an explicit resolution instead (e.g. 1080p) requires a new stream, which can't happen mid-broadcast. Variable is the enabler for the whole auto step-down feature.
+
+### Compliance gotchas (maps perfectly to report-by-exception)
+
+- `liveStreams.status.healthStatus`: `good | ok | bad | noData` plus `configurationIssues[]` with `type` + `severity` (`info|warning|error`). Literally built for report-by-exception β poll it, render nothing on good/ok, surface a banner only on warning/error. No need to invent our own health logic.
+- Encoder must comply or YouTube flags it: keyframes β€ 4s (`gopSizeLong`), closed GOP, H.264, audio AAC/MP3 @ 44.1/48kHz, mono/stereo only.
+- Error codes to handle: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`.
+
+### Tips we should take advantage of
+
+- **Reusable streams** (`isReusable=true`): one stream per channel, cache its ingestion URL + stream name, reuse for every broadcast. No rebinding dance each go-live. This is exactly the manual-stream-key baseline.
+- **Backup ingestion address**: YouTube provides a simultaneous-push backup β future hardening, not v1.
+- **`recordFromStart` + `enableDvr` default true** β every live is auto-recorded and immediately replayable. Free VOD archive, matches the v0.2 recording goal.
+- **`latencyPreference`: `normal | low | ultraLow`** β for homelab streamers talking to chat, `low` (or `ultraLow`, capped at 1080p) is a real feature.
+- **Broadcast ID == Video ID** β one ID to track everything.
+
+---
## TASK 1 β Initial Scaffold
@@ -25,7 +56,7 @@
### Requirements:
-1. **Google Cloud OAuth credentials** β client ID + secret (user provides; needs "Localhost" redirect URI)
+1. **Google Cloud OAuth credentials** β client ID + secret, **baked into `Helpers/OAuthCredentials.cs`** (desktop "Desktop app" OAuth client; loopback callback β no console redirect URI registration needed; creators never configure)
2. **Local HTTP listener** β `HttpListener` on `http://localhost:PORT/oauth2/callback` to catch the redirect
3. **Browser launch** β open the authorization URL in the default browser
4. **Token persistence** β store access/refresh tokens securely (Windows DPAPI), reload on startup
@@ -38,24 +69,79 @@
- Verify token refresh triggers when near expiry
- Verify credential load/save roundtrip
-### Status: Not started
+### Status: πΆ UI flow done β auth wiring pending
+- β
Two-state Start/End Stream button, go-live dialog (account + title/description/visibility), red top bar, pulsing LIVE badge + elapsed timer, preview glow, taskbar red dot
+- β¬ Real OAuth2 with the baked-in Google credentials (desktop client type; loopback callback, no redirect registration)
+- β¬ Token persistence via Windows DPAPI, reload on startup
+- β¬ Account sign-in/change wired to YouTubeAuthService (currently simulated in GoLiveViewModel)
---
## TASK 3 β Capture Pipeline (Scenes/Sources)
-**Goal:** Real video preview in the center panel β display capture, window capture, webcam, images, text overlays, composited per scene.
+**Goal:** Real video preview in the center panel β the minimal source set below, composited per scene.
+
+### The Minimal Source Set (design decision β do not expand casually)
+
+ytLlive is YouTube-only and 90% of users are casual. OBS's long source list is off-putting; we ship
+the hot few and nothing esoteric. If a user needs more, they've graduated to OBS.
+
+1. **Webcam** β the face cam. Non-negotiable.
+2. **Screen** β the main event (game, slides, browser). One source; a picker chooses a monitor *or* a
+ window. (Window capture is absorbed here β no separate source type.)
+3. **Background** β a full-canvas backdrop image. Fills the whole scene automatically, zero fiddling.
+ Kept separate from Image on purpose: same pixels, but this one needs no positioning.
+4. **Image** β a floating graphic/logo overlay (watermark, badge, corner branding). Free-positioned.
+5. **Text** β live text ("Starting soon", "Back in 5", handle, callout). Casual streamers live on this.
+6. **Chat box** β YouTube live chat rendered *on* the stream so viewers read along in-video. YT-native.
+7. **Alerts** β Super Chat / membership / subscribe pop-ins. The dopamine source. **The one big lift**
+ (Super Chat event streaming + on-stream rendering/animation); build after the six. **Also the one
+ paid feature** β see Monetization in `ai.md`.
+
+Deliberately NOT supported: game capture, browser source, media playlist, VLC, color-key voodoo, MIDI.
+
+### Source memory model (design decision)
+
+- A scene has resources. Resources can be shared across scenes.
+- A resource exists exactly once in memory, no matter how many scenes use it (a logo in five
+ scenes = one loaded bitmap).
+- Every resource carries a **catalog of scenes**: one usage entry per scene it appears in, each
+ entry dictating that scene's use β placement (X/Y/Width/Height), opacity, z-order, enabled,
+ scale mode, crop.
+- Usages are named `{resourceName}.{sceneName}` β whatever the user named the resource, dot, the
+ scene name: `logo.starting`, `logo.live`, `myPic.brb`. Not a hardcoded "logo".
+- A webcam in two scenes = one capture session, two catalog entries.
+- Refcount by catalog size: the last usage removed β the resource is disposed and evicted.
+- The resource (not a per-scene node) owns everything `IDisposable`.
+
+### Scene transitions (design decision)
+
+Scene switching while live must never stutter. Supported types, most β least economical:
+
+1. **Cut** β instant switch. The default. Zero cost.
+2. **Fade** β short crossfade (~300ms).
+3. **Move** β a simple, economical move transition, done to perfection and memory-efficient. The
+ smart streamer's bread and butter.
+4. **Custom (media) transitions** β require media elements (video/stinger playback during the
+ transition). Heavier, but creators pay for these, so we support them. Their media follows the
+ same resource memory model: loaded once, catalogued by scene.
+
+Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four above.
### Requirements:
-1. **Display capture** β Windows.Graphics.Capture API (WinRT), enumerate monitors
-2. **Window capture** β enumerate top-level windows, capture per-window
-3. **Webcam** β MediaCapture (WinRT) with device enumeration
-4. **Image / text overlay** β static sources positioned/scaled/opacity
-5. **Scene compositing** β per-scene source layering (z-order), preview rendered via D3DImage or MediaElement
-6. **Drag/drop source placement** β intuitive, visual (per design principle)
+1. **Screen** β Windows.Graphics.Capture (WinRT), enumerate displays/windows, picker
+2. **Webcam** β MediaCapture (WinRT) with device enumeration
+3. **Background / Image / Text** β static sources positioned/scaled/opacity
+4. **Chat box** β rendered from the live chat poll (right panel is the same feed, raw)
+5. **Scene compositing** β per-scene source layering (z-order = sources list order, top-to-bottom
+ back-to-front), preview rendered via D3DImage or MediaElement
+6. **Drag/drop placement & reorder** β intuitive, visual (per design principle):
+ - **Preview:** click-drag a source in the center panel to reposition it; resize via handles
+ - **Scenes list:** drag rows to reorder scenes
+ - **Sources list:** drag rows to reorder sources (this *is* the z-order) β implemented
-### Status: Not started
+### Status: πΆ In progress β scenes/sources UI built (add/reorder/rename, image + background overlays with move/resize/opacity/reuse); real capture/encoding pending
---
@@ -65,11 +151,23 @@
### Requirements:
-1. **Encoding** β H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio
-2. **RTMP push** β FFmpeg subprocess or native RTMP library
-3. **Stream key management** β save keys securely, prefill default YouTube ingest URL `rtmp://a.rtmp.youtube.com/live2`
-4. **Health stats** β bitrate, FPS, dropped frames reported live in the bottom bar
-5. **One-click go live** β defaults that work out of the box
+1. **Encoding** β H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes β€ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only
+2. **RTMP push** β FFmpeg subprocess or native RTMP library, to the cached reusable stream's ingestion URL
+3. **Quality ladder** β the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**:
+ - Phone 480p @ 2.5 Mbps
+ - 720p60 @ 6 Mbps
+ - 1080p30 @ 8 Mbps
+ - **1080p60 @ 8 Mbps** (default β mainstream ceiling, GPU hardware-encoded so the gaming
+ machine never notices; upload headroom stays comfortable)
+ - 1440p/4K = the paid unlock tiers (monetization), not the standard offering
+ Ladder is sculpted by a **cached probe** (IP-only TCP vs public ingest host; no auth required).
+ Quality is greyed out while live because the declared resolution can't change mid-stream β but
+ with `variable`, we can **auto step-down** bitrate/resolution on the fly with zero API calls
+ (no stream recreation); 60fps presumes a hardware encoder β no hardware encoder β auto
+ fallback to 720p60/1080p30
+4. **Stream key management** β reuse the cached reusable stream (one per channel) instead of creating a new one per go-live; prefill default YouTube ingest URL `rtmp://a.rtmp.youtube.com/live2`
+5. **Health stats** β bitrate, FPS, dropped frames reported live in the bottom bar (encoder-side)
+6. **One-click go live** β defaults that work out of the box
### Status: Not started
@@ -77,19 +175,57 @@
## TASK 5 β YouTube Live Stream Management
-**Goal:** Create/bind broadcasts, monitor YouTube-side stream health.
+**Goal:** Create/bind broadcasts, monitor YouTube-side stream health β the v3 way.
+
+### Design decisions (v3)
+
+1. **One-click go-live** β `liveBroadcasts.insert` with `enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`, `selfDeclaredMadeForKids=false`, `latencyPreference=low`. No `transition(live)` call, no testing stage, no liveStarting polling. Encoder starts β YouTube brings it live by itself.
+2. **Variable reusable stream** β `cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`. Create once per channel, cache ingestion URL + stream name, reuse for every broadcast. Any quality tier works without recreation; auto step-down needs no API calls.
+3. **Report-by-exception** β poll `liveStreams.list`; banner only on `healthStatus` warning/error issues (`configurationIssues[]`). Bottom strip = YouTube logo + green/red connection dot (clickable β opens the dialog).
+4. **One dialog, three states** β `not connected` (sign-in) / `connected-offline` (all editable) / `live` (title + description + visibility editable; quality + account greyed out). Both entry points (Start Stream button + bottom strip) open it; prefilled from saved session profile.
+5. **Live edits** β `liveBroadcasts.update` with part=`snippet,status` for title/description/privacy.
+6. **End stream** β stop encoder β `transition(complete)`, with `enableAutoStop` as the safety net.
+7. **Broadcast ID == Video ID** β one ID to track status, health, and the auto-created VOD (`recordFromStart` + `enableDvr`).
### Requirements:
-1. **Broadcast creation** β title/description/visibility via API
-2. **Stream binding** β create stream, bind to broadcast
-3. **Health monitoring** β poll `liveBroadcasts` lifecycle status, surface YouTube health messages
+1. **Broadcast creation** β title/description/privacy/scheduledStartTime via API, with the v3 flags above
+2. **Reusable stream** β create once, cache + reuse; bind to broadcast
+3. **Health monitoring** β poll `liveStreams.list` `healthStatus` + `configurationIssues[]`, surface banner only on warning/error
4. **Live chat** β poll `liveChat/messages`, render in right panel, support Super Chat + membership badges
+5. **Error handling** β the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`
### Status: Not started
---
+## TASK 6 β Layout Persistence (SQLite)
+
+**Goal:** Scenes, sources, and asset bytes survive restarts; assets are always available.
+
+### Design decisions
+
+1. **SQLite database** (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned
+ via `PRAGMA user_version`.
+2. **Assets live in the DB, not on disk** β `Asset` table stores image bytes (BLOB) keyed by a
+ SHA-256 content hash (unique). Identical image content collapses to one row regardless of file
+ name β the 1:M resource memory model, enforced by the database. No file paths; deleting the
+ original file never breaks a scene.
+3. **File-model save/open** β the active layout file is tracked (default is the AppData DB).
+ **Save Layout Asβ¦ / Open Layoutβ¦** switch the active file; auto-save writes to whatever is active.
+4. **Auto-save (invisible)** β ~1.5s debounce on scene add/remove/reorder/rename/hide, source
+ add/remove/reorder, and any source transform change; flush on window close.
+5. **Schema** β `Scene` (Id, Name, IsHidden, IsChatScene, SortOrder), `Asset` (Id, Hash, Data,
+ PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
+ X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, SortOrder). `WindowHandle` stays in-memory
+ (per-session). Save = transactional rewrite; orphaned assets pruned.
+6. **Startup** β load the active file; seed the default five scenes (Starting/Live/BRB/Chat/Ending)
+ only when the DB is empty.
+
+### Status: β
Implemented
+
+---
+
## Backlog (future versions)
- v0.2 β Recording to local file
diff --git a/ViewModels/GoLiveViewModel.cs b/ViewModels/GoLiveViewModel.cs
index 6eeb172..91b993f 100644
--- a/ViewModels/GoLiveViewModel.cs
+++ b/ViewModels/GoLiveViewModel.cs
@@ -5,34 +5,10 @@ namespace ytLive.ViewModels;
public class GoLiveViewModel : ViewModelBase
{
- private bool _isSignedIn;
- private string _accountName = string.Empty;
private string _streamTitle = string.Empty;
private string _streamDescription = string.Empty;
private string _visibility = "Public";
- public bool IsSignedIn
- {
- get => _isSignedIn;
- set
- {
- if (SetProperty(ref _isSignedIn, value))
- {
- OnPropertyChanged(nameof(ShowSignIn));
- OnPropertyChanged(nameof(ShowAccount));
- }
- }
- }
-
- public bool ShowSignIn => !IsSignedIn;
- public bool ShowAccount => IsSignedIn;
-
- public string AccountName
- {
- get => _accountName;
- set => SetProperty(ref _accountName, value);
- }
-
public string StreamTitle
{
get => _streamTitle;
@@ -53,33 +29,15 @@ public class GoLiveViewModel : ViewModelBase
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
- public ICommand SignInCommand { get; }
- public ICommand ChangeAccountCommand { get; }
public ICommand StartCommand { get; }
public ICommand CancelCommand { get; }
public GoLiveViewModel()
{
- SignInCommand = new RelayCommand(_ => SignIn());
- ChangeAccountCommand = new RelayCommand(_ => ChangeAccount());
- StartCommand = new RelayCommand(_ => StartRequested?.Invoke(), _ => IsSignedIn);
+ StartCommand = new RelayCommand(_ => StartRequested?.Invoke());
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
}
public event Action? StartRequested;
public event Action? CancelRequested;
-
- private void SignIn()
- {
- // TODO: OAuth2 flow; for now simulate a successful sign-in
- AccountName = "Connected Channel";
- IsSignedIn = true;
- }
-
- private void ChangeAccount()
- {
- // TODO: re-run OAuth2; for now simulate signing out
- IsSignedIn = false;
- AccountName = string.Empty;
- }
}
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 905b451..c90d406 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -1,7 +1,13 @@
using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
using System.Windows;
using System.Windows.Input;
+using System.Windows.Media;
using System.Windows.Threading;
+using Microsoft.Win32;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
@@ -16,12 +22,15 @@ public class MainViewModel : ViewModelBase
private readonly DispatcherTimer _liveTimer;
private Scene? _activeScene;
+ private Source? _selectedSource;
+ private ImageSource? _activeBackgroundImage;
+ private bool _isConnected;
private StreamStatus _streamStatus = StreamStatus.Offline;
private StreamHealth _currentHealth = new();
private string _streamTitle = string.Empty;
private string _streamDescription = string.Empty;
private string _streamVisibility = "Public";
- private string _windowTitle = "ytLive";
+ private string _windowTitle = "ytLlive";
private string _topBarBackground = "#16213e";
private string _previewGlowBrush = "Transparent";
private Thickness _previewGlowThickness = new(0);
@@ -29,13 +38,60 @@ public class MainViewModel : ViewModelBase
private double _livePulseOpacity = 1.0;
private TimeSpan _liveElapsed;
+ private bool _isSettingsOpen;
+ private bool _isBugOpen;
+ private bool _isFeatureOpen;
+ private bool _isAboutOpen;
+ private string _overlayTitle = string.Empty;
+ private string _defaultStreamTitle = string.Empty;
+ private string _defaultStreamDescription = string.Empty;
+ private string _defaultStreamVisibility = "Public";
+ private string _bugReportText = string.Empty;
+ private string _bugReportEmail = string.Empty;
+ private string _featureRequestText = string.Empty;
+ private string _featureRequestEmail = string.Empty;
+
+ private LayoutStore _layoutStore;
+ private string? _activeLayoutPath;
+ private DispatcherTimer? _saveDebounce;
+ private bool _isLoading;
+
+ private const string SupportEmail = "gramps@llamachile.shop";
+ private const string ChannelUrl = "https://youtube.com/@llamachileshop";
+ public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";
+
public ObservableCollection Scenes { get; } = new();
public ObservableCollection ChatMessages { get; } = new();
+ public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
public Scene? ActiveScene
{
get => _activeScene;
- set => SetProperty(ref _activeScene, value);
+ set
+ {
+ if (value != null && value.IsHidden) return;
+ if (SetProperty(ref _activeScene, value))
+ {
+ SelectedSource = null;
+ OnPropertyChanged(nameof(ShowChatInactiveMessage));
+ OnPropertyChanged(nameof(ShowEmptySceneHint));
+ OnPropertyChanged(nameof(ShowPreviewPlaceholder));
+ OnPropertyChanged(nameof(ShowSourcesEmptyHint));
+ UpdateActiveBackground();
+ }
+ }
+ }
+
+ public ImageSource? ActiveBackgroundImage
+ {
+ get => _activeBackgroundImage;
+ private set => SetProperty(ref _activeBackgroundImage, value);
+ }
+
+ public Source? SelectedSource
+ {
+ get => _selectedSource;
+ set => SetProperty(ref _selectedSource, value);
}
public StreamStatus StreamStatus
@@ -48,14 +104,45 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(IsOffline));
OnPropertyChanged(nameof(IsLive));
OnPropertyChanged(nameof(LiveIndicatorVisible));
+ OnPropertyChanged(nameof(ShowStartStream));
+ OnPropertyChanged(nameof(ShowChatInactiveMessage));
+ OnPropertyChanged(nameof(ShowPreviewPlaceholder));
UpdateLiveVisuals();
}
}
}
+ public bool IsConnected
+ {
+ get => _isConnected;
+ set
+ {
+ if (SetProperty(ref _isConnected, value))
+ {
+ OnPropertyChanged(nameof(IsNotConnected));
+ OnPropertyChanged(nameof(ShowStartStream));
+ }
+ }
+ }
+
public bool IsOffline => StreamStatus == StreamStatus.Offline;
public bool IsLive => StreamStatus == StreamStatus.Streaming;
public bool LiveIndicatorVisible => IsLive;
+ public bool IsNotConnected => !IsConnected;
+ public bool ShowStartStream => IsConnected && IsOffline;
+ public bool ShowChatInactiveMessage => !IsLive;
+ public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Sources.Count == 0;
+ public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null;
+ public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Sources.Count == 0;
+
+ private void UpdateActiveBackground()
+ {
+ var background = ActiveScene?.Sources.FirstOrDefault(s => s.Type == SourceType.Background);
+ ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId)
+ ? ImageCache.Get(background.AssetId)
+ : null;
+ OnPropertyChanged(nameof(ShowPreviewPlaceholder));
+ }
public StreamHealth CurrentHealth
{
@@ -117,15 +204,134 @@ public class MainViewModel : ViewModelBase
set => SetProperty(ref _livePulseOpacity, value);
}
+ // Overlay panels
+ public bool IsSettingsOpen
+ {
+ get => _isSettingsOpen;
+ private set => SetPanel(ref _isSettingsOpen, value);
+ }
+
+ public bool IsBugOpen
+ {
+ get => _isBugOpen;
+ private set => SetPanel(ref _isBugOpen, value);
+ }
+
+ public bool IsFeatureOpen
+ {
+ get => _isFeatureOpen;
+ private set => SetPanel(ref _isFeatureOpen, value);
+ }
+
+ public bool IsAboutOpen
+ {
+ get => _isAboutOpen;
+ private set => SetPanel(ref _isAboutOpen, value);
+ }
+
+ public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;
+
+ public string OverlayTitle
+ {
+ get => _overlayTitle;
+ private set => SetProperty(ref _overlayTitle, value);
+ }
+
+ public string DefaultStreamTitle
+ {
+ get => _defaultStreamTitle;
+ set => SetProperty(ref _defaultStreamTitle, value);
+ }
+
+ public string DefaultStreamDescription
+ {
+ get => _defaultStreamDescription;
+ set => SetProperty(ref _defaultStreamDescription, value);
+ }
+
+ public string DefaultStreamVisibility
+ {
+ get => _defaultStreamVisibility;
+ set => SetProperty(ref _defaultStreamVisibility, value);
+ }
+
+ public string[] StreamQualities { get; } = { "1080p60", "1080p30", "720p60" };
+
+ private string _streamQuality = "1080p60";
+ public string StreamQuality
+ {
+ get => _streamQuality;
+ set
+ {
+ if (SetProperty(ref _streamQuality, value))
+ ApplyStreamQuality(value);
+ }
+ }
+
+ private void ApplyStreamQuality(string quality)
+ {
+ var (bitrate, fps) = quality switch
+ {
+ "1080p30" => (8.0, 30.0),
+ "720p60" => (6.0, 60.0),
+ _ => (8.0, 60.0),
+ };
+ var health = CurrentHealth;
+ health.CurrentBitrate = bitrate;
+ health.FPS = fps;
+ OnPropertyChanged(nameof(CurrentHealth));
+ }
+
+ public string BugReportText
+ {
+ get => _bugReportText;
+ set => SetProperty(ref _bugReportText, value);
+ }
+
+ public string BugReportEmail
+ {
+ get => _bugReportEmail;
+ set => SetProperty(ref _bugReportEmail, value);
+ }
+
+ public string FeatureRequestText
+ {
+ get => _featureRequestText;
+ set => SetProperty(ref _featureRequestText, value);
+ }
+
+ public string FeatureRequestEmail
+ {
+ get => _featureRequestEmail;
+ set => SetProperty(ref _featureRequestEmail, value);
+ }
+
// Commands
public ICommand AddSceneCommand { get; }
+ public ICommand EditSceneCommand { get; }
public ICommand RemoveSceneCommand { get; }
+ public ICommand ToggleSceneVisibilityCommand { get; }
+ public ICommand AddSourceCommand { get; }
+ public ICommand AddImageCommand { get; }
+ public ICommand RemoveSourceCommand { get; }
+ public ICommand ConnectCommand { get; }
public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; }
+ public ICommand OpenSettingsCommand { get; }
+ public ICommand OpenBugCommand { get; }
+ public ICommand OpenFeatureCommand { get; }
+ public ICommand OpenAboutCommand { get; }
+ public ICommand CloseOverlayCommand { get; }
+ public ICommand SubmitBugCommand { get; }
+ public ICommand SubmitFeatureCommand { get; }
+ public ICommand OpenChannelCommand { get; }
+ public ICommand SaveLayoutCommand { get; }
+ public ICommand SaveLayoutAsCommand { get; }
+ public ICommand OpenLayoutCommand { get; }
public MainViewModel()
{
- _youtubeAuth = new YouTubeAuthService("", ""); // TODO: load from config
+ _youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret);
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
_youtubeChat = new YouTubeChatService(_youtubeAuth);
@@ -134,22 +340,191 @@ public class MainViewModel : ViewModelBase
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_liveTimer.Tick += OnLiveTimerTick;
+ _saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
+ _saveDebounce.Tick += (_, _) => SaveLayoutNow();
+ Scenes.CollectionChanged += OnScenesChanged;
+
AddSceneCommand = new RelayCommand(_ => AddScene());
+ EditSceneCommand = new RelayCommand(scene => BeginEditScene(scene as Scene));
RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene));
+ ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
+ AddSourceCommand = new RelayCommand(type => AddSource(type as string));
+ AddImageCommand = new RelayCommand(_ => AddImage());
+ RemoveSourceCommand = new RelayCommand(source => RemoveSource(source as Source));
+ OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
+ OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
+ OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
+ OpenAboutCommand = new RelayCommand(_ => ShowOverlay(nameof(IsAboutOpen), "About"));
+ CloseOverlayCommand = new RelayCommand(_ => ShowOverlay());
+ SubmitBugCommand = new RelayCommand(_ => SubmitBug());
+ SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature());
+ OpenChannelCommand = new RelayCommand(_ => OpenUrl(ChannelUrl));
+ ConnectCommand = new RelayCommand(_ => _ = ConnectAsync());
StartStreamCommand = new RelayCommand(_ => BeginGoLive());
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive);
+ SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
+ SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs());
+ OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());
- // Start with a default scene
- AddScene("Scene 1");
+ _layoutStore = new LayoutStore(DefaultLayoutPath);
+ _activeLayoutPath = _layoutStore.ActivePath;
+ LoadLayout();
}
- private void AddScene(string? name = null)
+ private static string DefaultLayoutPath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "ytLlive",
+ "ytLlive.db");
+
+ private void LoadLayout()
{
- var scene = new Scene { Name = name ?? $"Scene {Scenes.Count + 1}" };
+ _isLoading = true;
+ try
+ {
+ var scenes = _layoutStore.Load();
+ Scenes.Clear();
+ foreach (var scene in scenes)
+ Scenes.Add(scene);
+
+ if (Scenes.Count == 0)
+ {
+ AddScene("Starting");
+ AddScene("Live");
+ AddScene("BRB");
+ AddScene("Chat", isChatScene: true);
+ AddScene("Ending");
+ }
+ }
+ finally
+ {
+ _isLoading = false;
+ }
+ ActiveScene = Scenes.FirstOrDefault();
+ UpdateActiveBackground();
+ ScheduleSave();
+ }
+
+ public void Shutdown()
+ {
+ _saveDebounce?.Stop();
+ SaveLayoutNow();
+ _layoutStore.Dispose();
+ }
+
+ public void SaveLayoutNow()
+ {
+ _saveDebounce?.Stop();
+ try
+ {
+ _layoutStore.Save(Scenes);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Layout save failed: {ex.Message}");
+ }
+ }
+
+ private void SaveLayoutAs()
+ {
+ var dialog = new SaveFileDialog
+ {
+ Title = "Save Layout As",
+ Filter = "ytLlive 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 = "ytLlive 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)
+ {
+ if (e.NewItems != null)
+ foreach (Scene scene in e.NewItems)
+ WireScene(scene);
+ if (e.OldItems != null)
+ foreach (Scene scene in e.OldItems)
+ UnwireScene(scene);
+ ScheduleSave();
+ }
+
+ private void WireScene(Scene scene)
+ {
+ scene.PropertyChanged += OnScenePropertyChanged;
+ scene.Sources.CollectionChanged += OnSourcesChanged;
+ }
+
+ private void UnwireScene(Scene scene)
+ {
+ scene.PropertyChanged -= OnScenePropertyChanged;
+ scene.Sources.CollectionChanged -= OnSourcesChanged;
+ }
+
+ private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
+ => ScheduleSave();
+
+ private void OnSourcesChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (e.NewItems != null)
+ foreach (Source source in e.NewItems)
+ source.PropertyChanged += OnSourcePropertyChanged;
+ if (e.OldItems != null)
+ foreach (Source source in e.OldItems)
+ source.PropertyChanged -= OnSourcePropertyChanged;
+ ScheduleSave();
+ }
+
+ private void OnSourcePropertyChanged(object? sender, PropertyChangedEventArgs e)
+ => ScheduleSave();
+
+ private void ScheduleSave()
+ {
+ if (_isLoading || _saveDebounce == null) return;
+ _saveDebounce.Stop();
+ _saveDebounce.Start();
+ }
+
+ private void AddScene(string? name = null, bool isChatScene = false)
+ {
+ var scene = new Scene { Name = name ?? $"New Scene {Scenes.Count + 1}", IsChatScene = isChatScene };
Scenes.Add(scene);
ActiveScene = scene;
}
+ private void BeginEditScene(Scene? scene)
+ {
+ if (scene == null) return;
+ scene.IsEditing = true;
+ }
+
+ private void ToggleSceneVisibility(Scene? scene)
+ {
+ if (scene == null) return;
+ scene.IsHidden = !scene.IsHidden;
+ if (scene.IsHidden && ActiveScene == scene)
+ ActiveScene = Scenes.FirstOrDefault(s => !s.IsHidden);
+ }
+
private void RemoveScene(Scene? scene)
{
if (scene == null) return;
@@ -158,6 +533,257 @@ public class MainViewModel : ViewModelBase
ActiveScene = Scenes.FirstOrDefault();
}
+ private void AddSource(string? type)
+ {
+ var scene = ActiveScene;
+ if (scene == null) return;
+
+ var sourceType = type?.ToLowerInvariant() switch
+ {
+ "webcam" => SourceType.Webcam,
+ "screen" => SourceType.DisplayCapture,
+ "window" => SourceType.WindowCapture,
+ "background" => SourceType.Background,
+ "text" => SourceType.TextOverlay,
+ _ => SourceType.Image,
+ };
+ var baseName = sourceType switch
+ {
+ SourceType.Webcam => "Webcam",
+ SourceType.DisplayCapture => "Screen",
+ SourceType.WindowCapture => "Window",
+ SourceType.Background => "Background",
+ SourceType.Image => "Image",
+ SourceType.TextOverlay => "Text",
+ _ => "Source",
+ };
+
+ if (sourceType == SourceType.Background)
+ {
+ var bytes = PickImageBytes("Choose a backdrop image");
+ if (bytes == null) return;
+ var assetId = AddAsset(bytes);
+ if (assetId == null) return;
+
+ var existing = scene.Sources.FirstOrDefault(s => s.Type == SourceType.Background);
+ if (existing != null)
+ {
+ existing.AssetId = assetId;
+ UpdateActiveBackground();
+ return;
+ }
+
+ scene.Sources.Add(new Source { Name = baseName, Type = SourceType.Background, AssetId = assetId });
+ OnPropertyChanged(nameof(ShowEmptySceneHint));
+ OnPropertyChanged(nameof(ShowSourcesEmptyHint));
+ UpdateActiveBackground();
+ return;
+ }
+
+ var count = scene.Sources.Count(s => s.Type == sourceType);
+ var name = count == 0 ? baseName : $"{baseName} {count + 1}";
+
+ scene.Sources.Add(new Source { Name = name, Type = sourceType });
+ OnPropertyChanged(nameof(ShowEmptySceneHint));
+ OnPropertyChanged(nameof(ShowSourcesEmptyHint));
+ UpdateActiveBackground();
+ }
+
+ private void AddImage()
+ {
+ var scene = ActiveScene;
+ if (scene == null) return;
+
+ var candidates = BuildImageCandidates();
+ if (candidates.Count == 0)
+ {
+ var bytes = PickImageBytes("Choose an image");
+ if (bytes != null) AddImageSource(bytes);
+ return;
+ }
+
+ var dialog = new ReuseImageDialog(new ReuseImageViewModel(candidates))
+ {
+ Owner = Application.Current.MainWindow
+ };
+ if (dialog.ShowDialog() != true) return;
+
+ if (dialog.WantsNew)
+ {
+ var bytes = PickImageBytes("Choose an image");
+ if (bytes != null) AddImageSource(bytes);
+ }
+ else
+ {
+ AddReusedImage(dialog.PickedAssetId!);
+ }
+ }
+
+ private List BuildImageCandidates()
+ {
+ var candidates = new List();
+ foreach (var scene in Scenes)
+ foreach (var source in scene.Sources.Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId)))
+ {
+ candidates.Add(new ReuseImageCandidate
+ {
+ SceneName = scene.Name,
+ SourceName = source.Name,
+ AssetId = source.AssetId!
+ });
+ }
+ return candidates;
+ }
+
+ private static byte[]? PickImageBytes(string title)
+ {
+ var dialog = new OpenFileDialog
+ {
+ Title = title,
+ Filter = "Image files (*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp)|*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp|All files (*.*)|*.*"
+ };
+ if (dialog.ShowDialog() != true) return null;
+ try
+ {
+ return File.ReadAllBytes(dialog.FileName);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Couldn't read that image: {ex.Message}", "ytLlive",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ return null;
+ }
+ }
+
+ private string? AddAsset(byte[] bytes)
+ {
+ try
+ {
+ var image = ImageCache.FromBytes(bytes);
+ var id = _layoutStore.UpsertAsset(bytes, image?.PixelWidth ?? 0, image?.PixelHeight ?? 0);
+ if (id != null && image != null) ImageCache.Put(id, image);
+ return id;
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Asset store failed: {ex.Message}");
+ return null;
+ }
+ }
+
+ private void AddImageSource(byte[] bytes)
+ {
+ if (bytes.Length == 0) return;
+ var assetId = AddAsset(bytes);
+ if (assetId != null) AddReusedImage(assetId);
+ }
+
+ private void AddReusedImage(string assetId)
+ {
+ var scene = ActiveScene;
+ if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
+
+ var count = scene.Sources.Count(s => s.Type == SourceType.Image);
+ var name = count == 0 ? "Image" : $"Image {count + 1}";
+
+ var source = new Source { Name = name, Type = SourceType.Image, AssetId = assetId };
+
+ var image = ImageCache.Get(assetId);
+ if (image != null)
+ {
+ var scale = Math.Min(640.0 / image.PixelWidth, 480.0 / image.PixelHeight);
+ source.Width = image.PixelWidth * scale;
+ source.Height = image.PixelHeight * scale;
+ source.X = (1920 - source.Width) / 2;
+ source.Y = (1080 - source.Height) / 2;
+ }
+
+ scene.Sources.Add(source);
+ SelectedSource = source;
+ OnPropertyChanged(nameof(ShowEmptySceneHint));
+ OnPropertyChanged(nameof(ShowSourcesEmptyHint));
+ UpdateActiveBackground();
+ }
+
+ private void RemoveSource(Source? source)
+ {
+ var scene = ActiveScene;
+ if (scene == null || source == null) return;
+ scene.Sources.Remove(source);
+ OnPropertyChanged(nameof(ShowEmptySceneHint));
+ OnPropertyChanged(nameof(ShowSourcesEmptyHint));
+ UpdateActiveBackground();
+ }
+
+ private void SetPanel(ref bool field, bool value, [System.Runtime.CompilerServices.CallerMemberName] string? propertyName = null)
+ {
+ if (SetProperty(ref field, value, propertyName))
+ OnPropertyChanged(nameof(IsAnyOverlayOpen));
+ }
+
+ private void ShowOverlay(string? panel = null, string title = "")
+ {
+ IsSettingsOpen = panel == nameof(IsSettingsOpen);
+ IsBugOpen = panel == nameof(IsBugOpen);
+ IsFeatureOpen = panel == nameof(IsFeatureOpen);
+ IsAboutOpen = panel == nameof(IsAboutOpen);
+ OverlayTitle = title;
+ }
+
+ private void SubmitBug()
+ {
+ var body = string.Join(Environment.NewLine,
+ BugReportText.Trim(),
+ string.Empty,
+ $"ytLlive {AppVersionLabel}",
+ string.IsNullOrWhiteSpace(BugReportEmail) ? string.Empty : $"Reply-to: {BugReportEmail.Trim()}");
+ ComposeEmail("[ytLlive Bug Report]", body);
+ ShowOverlay();
+ }
+
+ private void SubmitFeature()
+ {
+ var body = string.Join(Environment.NewLine,
+ FeatureRequestText.Trim(),
+ string.Empty,
+ $"ytLlive {AppVersionLabel}",
+ string.IsNullOrWhiteSpace(FeatureRequestEmail) ? string.Empty : $"Reply-to: {FeatureRequestEmail.Trim()}");
+ ComposeEmail("[ytLlive Feature Request]", body);
+ ShowOverlay();
+ }
+
+ private static void ComposeEmail(string subject, string body)
+ {
+ var uri = $"mailto:{SupportEmail}?subject={Uri.EscapeDataString(subject)}&body={Uri.EscapeDataString(body)}";
+ OpenUrl(uri);
+ }
+
+ private static void OpenUrl(string url)
+ {
+ Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ }
+
+ private async Task ConnectAsync()
+ {
+ try
+ {
+ var channel = await Task.Run(() => _youtubeAuth.AuthenticateAsync());
+ if (channel == null)
+ {
+ MessageBox.Show("Sign-in was unsuccessful. Please try again.", "ytLlive",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ IsConnected = true;
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Sign-in failed: {ex.Message}", "ytLlive",
+ MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
private void OnChatMessageReceived(ChatMessage message)
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
@@ -170,7 +796,12 @@ public class MainViewModel : ViewModelBase
private void BeginGoLive()
{
- var dialog = new GoLiveViewModel();
+ var dialog = new GoLiveViewModel
+ {
+ StreamTitle = DefaultStreamTitle,
+ StreamDescription = DefaultStreamDescription,
+ Visibility = DefaultStreamVisibility,
+ };
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
if (window.ShowDialog() == true)
{
@@ -178,8 +809,8 @@ public class MainViewModel : ViewModelBase
StreamDescription = dialog.StreamDescription;
StreamVisibility = dialog.Visibility;
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
- ? "ytLive"
- : $"{dialog.StreamTitle} β ytLive";
+ ? "ytLlive"
+ : $"{dialog.StreamTitle} β ytLlive";
StreamStatus = StreamStatus.Streaming;
}
}
@@ -187,7 +818,7 @@ public class MainViewModel : ViewModelBase
private void StopStream()
{
StreamStatus = StreamStatus.Offline;
- WindowTitle = "ytLive";
+ WindowTitle = "ytLlive";
}
private void UpdateLiveVisuals()
diff --git a/ViewModels/ReuseImageViewModel.cs b/ViewModels/ReuseImageViewModel.cs
new file mode 100644
index 0000000..709812d
--- /dev/null
+++ b/ViewModels/ReuseImageViewModel.cs
@@ -0,0 +1,52 @@
+using System.Collections.ObjectModel;
+using System.Windows.Input;
+using System.Windows.Media;
+using ytLive.Helpers;
+
+namespace ytLive.ViewModels;
+
+public class ReuseImageCandidate
+{
+ public string SceneName { get; set; } = string.Empty;
+ public string SourceName { get; set; } = string.Empty;
+ public string AssetId { get; set; } = string.Empty;
+
+ public ImageSource? Thumbnail => ImageCache.Get(AssetId);
+ public string Header => $"{SourceName} Β· in {SceneName}";
+}
+
+public class ReuseImageViewModel : ViewModelBase
+{
+ private ReuseImageCandidate? _selectedCandidate;
+
+ public ObservableCollection Candidates { get; }
+
+ public ReuseImageCandidate? SelectedCandidate
+ {
+ get => _selectedCandidate;
+ set
+ {
+ if (SetProperty(ref _selectedCandidate, value))
+ CommandManager.InvalidateRequerySuggested();
+ }
+ }
+
+ public ICommand UseSelectedCommand { get; }
+ public ICommand NewImageCommand { get; }
+ public ICommand CancelCommand { get; }
+
+ public event Action? ReuseRequested;
+ public event Action? NewImageRequested;
+ public event Action? CancelRequested;
+
+ public ReuseImageViewModel(IEnumerable candidates)
+ {
+ Candidates = new ObservableCollection(candidates);
+ if (Candidates.Count > 0)
+ _selectedCandidate = Candidates[0];
+
+ UseSelectedCommand = new RelayCommand(_ => ReuseRequested?.Invoke(), _ => SelectedCandidate != null);
+ NewImageCommand = new RelayCommand(_ => NewImageRequested?.Invoke());
+ CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
+ }
+}
diff --git a/ai.md b/ai.md
index 8237380..b936fee 100644
--- a/ai.md
+++ b/ai.md
@@ -1,4 +1,4 @@
-# ytLive β AI Guide
+# ytLlive β AI Guide
## Run
@@ -21,8 +21,8 @@ C# / WPF (.NET 8) following MVVM:
|------|------|
| `Models/` | Plain data types β Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
| `ViewModels/` | MainViewModel β exposes collections + commands for the UI |
-| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling |
-| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand |
+| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite) |
+| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache |
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
### Key patterns
@@ -31,15 +31,17 @@ C# / WPF (.NET 8) following MVVM:
- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline)
- ViewModels are constructed in XAML (`` as DataContext)
- Services are currently instantiated in MainViewModel's constructor β no DI container yet
+- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths β assets are always available)
### Current limitations / TODOs
-- `YouTubeAuthService` constructor takes empty client ID/secret strings β needs Google Cloud credentials
-- `StartStream` is a stub (Task.Delay simulation)
-- `ConnectYouTube` is a stub
-- Preview panel is placeholder text
+- `OAuthCredentials.ClientId` / `ClientSecret` in `Helpers/OAuthCredentials.cs` are empty β the app
+ owner fills them in once (developer task, baked into the binary; creators never configure anything)
+- `GoLiveViewModel.SignIn`/`ChangeAccount` removed β Connect (OAuth) is the only entry to streaming
+- No token persistence yet (Windows DPAPI planned) β scene/source/asset layout *does* persist (SQLite)
+- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams β must switch to the v3 `variable` reusable stream
- No capture/encoding/RTMP yet
-- No persistence layer (tokens, scenes, stream config all in-memory)
+- Stream config (title/description/visibility/quality) still in-memory
## Design Principle
@@ -51,9 +53,58 @@ Apply this to every UI decision:
- Visual/drag-and-drop scene building over property panels
- Every action produces a visible outcome β no dead ends
+## Monetization (design decision β the watermark is the sword)
+
+Free forever: all streams unlimited, no time caps, no subscription, no per-feature paywalls. The
+**one paid line is a one-time unlock** (delivered via itch.io β they handle hosting, payment, and key
+delivery; we never own a server or a key shop):
+
+- **Free:** a small "made with ytLlive" watermark is always on, every frame, every stream β the sword
+ of Damocles. Standard practice; only Streamlabs runs watermark-nagging to a capitalist extreme.
+- **Paid (one-time):** watermark removed + **Alerts** (Super Chat / membership / subscribe pop-ins).
+
+Deliberately rejected: hard stream-time cutoffs (the worst dead end β a stream dying mid-broadcast
+reads as broken, and YouTube streams routinely run 2-4 hours), soft-limit nagging, freemium tiers,
+and donation-only (relies on the kindness of strangers). Resolution/quality ceilings are **deferred** β
+that decision belongs to the resolution & streaming-constraints conversation, not monetization.
+
## Auth gates Go Live, but not exploration
The app is fully usable without authentication: users can build scenes, add sources, compose
previews, and audition the software with zero commitment. But **going live requires authentication** β
it's the one capability gated behind YouTube sign-in. The sign-in button should never pressure the
user ("sign in (optional)", not a modal wall), but "Go Live" only appears once connected.
+
+## Account assumption (do not build an account setup flow)
+
+Connecting uses Google OAuth ("Sign in with Google") to link an **existing** YouTube creator
+account. ytLlive **never creates or sets up accounts** β that is YouTube's job. If the creator has no
+YouTube channel, they go to YouTube first. This assumption is explicit and must never be silently
+replaced by an in-app account-creation step. Zero state = a Connect button that starts OAuth; going
+live is unreachable until the account is connected.
+
+## YouTube Live API β design constraints (do not violate)
+
+These are the hard facts behind every decision. Full list in `TASKS.md`.
+
+- **One-click go-live** β never call `transition(live)`. Insert the broadcast with
+ `enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`,
+ `selfDeclaredMadeForKids=false`, `latencyPreference=low`. The encoder starting brings YouTube live.
+ `enableMonitorStream=false` is what lets us skip the testing stage.
+- **Variable reusable stream** β `liveStreams.insert` once per channel with
+ `cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`; cache the ingestion URL +
+ stream name and reuse for every broadcast. Any quality tier works without recreating the stream,
+ and auto step-down is done by us dropping bitrate on the fly (zero API calls).
+- **Quality is greyed out while live** β resolution/frameRate/ingestionType are immutable after
+ stream creation; editing title/description/privacy is fine at any time.
+- **Report-by-exception health** β poll `liveStreams.list`; render nothing on `good`/`ok`, surface a
+ banner only on `configurationIssues[]` with `warning`/`error` severity. Bottom strip = YouTube logo
+ + green/red connection dot (clickable β opens the dialog).
+- **One dialog, three states** β not connected / connected-offline (all editable) / live
+ (title + description + visibility editable; quality + account greyed out). Both entry points
+ (Start Stream button + bottom strip) open it; prefilled from saved session profile.
+- **End stream** β stop encoder β `transition(complete)`, `enableAutoStop` as the safety net.
+- **Encoder compliance** β keyframes β€ 4s (gopSizeLong), closed GOP, H.264, AAC/MP3 @ 44.1/48kHz,
+ mono/stereo only. YouTube flags violations via health status.
+- **Broadcast ID == Video ID** β one ID tracks status, health, and the auto-created VOD
+ (`recordFromStart` + `enableDvr` default true).
diff --git a/ytLive.csproj b/ytLive.csproj
index d477719..5caf921 100644
--- a/ytLive.csproj
+++ b/ytLive.csproj
@@ -7,9 +7,19 @@
enable
true
true
-
+ Assets\llama-logo.ico
ytLive
ytLive
+
+
+
+
+
+
+
+
+
+